using System;
using System.IO;
using System.Xml.Serialization;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Soap;
using System.Threading;
using System.Diagnostics;
using System.Windows.Forms;

namespace MediaBackup
{
    // BrianSp: Represents a single media object, which can be in various stages in its lifetime.  It starts out
    // "Discovered" when first encountered in the TiVo listings, and subsequently is progressed through downloading,
    // decrypting and transcoding.  This object is serialized to disk as xml in the download location so it can be
    // reconstituted later and potentially on a different machine.
    [Serializable]
    public class Item
    {
        #region Inners
        public enum State
        {
            // The TiVo reported its existance
            Discovered = 0,

            // Downloading has begun
            TivoDownloading = 2,

            // Downloading has finished
            TivoDownloaded = 3,

            // Decrypting has begun
            TivoDecrypting = 4,

            // Decrypting has finished
            TivoDecrypted = 5,

            // Transcoding has begun
            Transcoding = 6,

            // Transcoding has finished
            Transcoded = 7,
        }
        #endregion

        #region Static Data
        // BrianSp: This is for the object serialization file.
        public const string FileExtension = ".MediaItem.xml";
        #endregion

        #region Static Methods

        public static bool ProgressNext(string dir, Tivo tivo, Settings settings)
        {
            foreach (string path in Directory.GetFiles(dir, "*" + FileExtension))
            {
                Item item = Acquire(path, tivo, settings);
                if (item == null)
                    continue;

                try
                {
                    bool didWork = item.Progress();
                    if (didWork)
                        return true;
                }
                finally
                {
                    item.Dispose();
                }
            }

            foreach (string subdir in Directory.GetDirectories(dir))
            {
                bool didWork = ProgressNext(subdir, tivo, settings);
                if (didWork)
                    return true;
            }

            return false;
        }

        // BrianSp: Static entry point to acquire exlusive access to, and instantiate, a particular media Item
        // specified by its seralized file path.
        public static Item Acquire(string path, Tivo tivo, Settings settings)
        {
            FileStream file = null;

            try
            {
                try
                {
                    // BrianSp always request exclusive write access, this is how we prevent multiple threads
                    // operating on the same media Item at the same time.
                    file = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 1, false);
                }
                catch (IOException)
                {
                    // BrianSp: Another thread probably has this item... no worries.
                    return null;
                }

                // BrianSp: I hate the soap formatter, is there a better xml formatter?
                SoapFormatter formatter = new SoapFormatter();
                formatter.AssemblyFormat = System.Runtime.Serialization.Formatters.FormatterAssemblyStyle.Simple;

                Item item;

                try
                {
                    item = (Item)formatter.Deserialize(file);
                }
                catch
                {
                    // BrianSp: The xml serialization is corrupt or otherwise not usable - close it down and delete the file
                    // so it gets regenerated next time it is discovered.

                    file.Close();
                    file.Dispose();
                    file = null;

                    File.Delete(path);

                    throw;
                }

                // BrianSp: After deserialization, hand the file stream to the object instance, as well as the current tivo and settings instances.
                item.SetTransientContext(file, tivo, settings);
                return item;
            }
            catch (Exception e)
            {
                if (file != null)
                {
                    // BrianSp: Make sure we don't hold the lock on this file if we aren't going to return an item.
                    file.Close();
                    file.Dispose();
                }

                Log.Write(e);
                return null;
            }
        }

        // BrianSp: Create a newly discovered item.  Seed it with the info from the TiVo that we are interested in.
        public static void Create(string title, string episode, ulong size, string uri, Tivo tivo, Settings settings)
        {
            FileStream file = null;

            try
            {
                // BrianSp: TiVo names come with characters that don't work in file names, so fix that first.
                string name = Sanitize(title);
                if (episode != null)
                {
                    // BrianSp: If this is episodic content, the title is the name of the series, so use that as a
                    // subdirectory under the TivoDownloadRoot.  The episode name becomes the file name.
                    name = Path.Combine(name, Sanitize(episode));
                }

                string path = Path.Combine(settings.TivoDownloadRootPath, name + FileExtension);

                // BrianSp: If the file already exists, nothing left to do.
                if (!File.Exists(path))
                {
                    Log.WriteLine(string.Format("Discovered \"{0}\"", name));

                    // BrianSp: Make sure the directory we want actually exists.
                    string dir = Path.GetDirectoryName(path);
                    if (!Directory.Exists(dir))
                        Directory.CreateDirectory(dir);

                    // BrianSp: Create the item serialization file.
                    file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 1, false);

                    // BrianSp: Construct a new item and immediately set it's state (which always saves the item to disk).
                    Item item = new Item(name, size, uri, file, tivo, settings);
                    item.SetState(State.Discovered);
                    item.Dispose();

                    // BrianSp: Don't bother returning it, it will get picked up by a thread doing ProgressNext at some point.
                }
            }
            catch (Exception e)
            {
                if (file != null)
                {
                    file.Close();
                    file.Dispose();
                }

                Log.Write(e);
            }
        }

        private static string Sanitize(string name)
        {
            // BrianSp: These two have slightly reasonable replacements
            name = name.Replace(':', '-');
            name = name.Replace('"', '\'');

            // BrianSp: Otherwise just use _ for invalid file name chars.
            foreach (char ch in Path.GetInvalidFileNameChars())
                name = name.Replace(ch, '_');

            return name;
        }

        // BrianSp: Callbacks are typically static, and take a context object (AsyncState in this case).  I almost
        // always use that context to move right back into instance method territory.
        private static void StaticTivoDownloadWriteCallback(IAsyncResult result)
        {
            Item o = (Item)result.AsyncState;
            o.TivoDownloadWriteCallback(result);
        }

        private static void StaticTivoDownloadReadCallback(IAsyncResult result)
        {
            Item o = (Item)result.AsyncState;
            o.TivoDownloadReadCallback(result);
        }

        #endregion

        #region Instance Data

        // BrianSp: This file is that which the item serialization is read from and written to.  It also acts as the synchronization
        // mechanism for multiple thread coordination.
        [XmlIgnore]
        [NonSerialized]
        private FileStream m_File;

        // BrianSp: The currently TiVo connection.
        [XmlIgnore]
        [NonSerialized]
        private Tivo m_Tivo;

        [XmlIgnore]
        [NonSerialized]
        private Settings m_Settings;

        // BrianSp: This file handle is stored as a non-serialized member so it survives asynchronous operation boundaries.
        [XmlIgnore]
        [NonSerialized]
        private FileStream m_TivoDownloadFile;

        // BrianSp: Doesn't seem like we should have to buffer this file, but I found it works best to do this.  Apparently the FileStream
        // will resort to synchronous IO for any writes under 64KB.  This way we can ensure all write are asynchronous.
        [XmlIgnore]
        [NonSerialized]
        private MemoryStream m_FileBuffer;

        // BrianSp: Everything else is serializable state....

        // BrianSp: Will be used shortly to enable a back-off mechanism for files that fail (possibly pathologically) at any point
        // in the pipeline.
        private byte m_Failures;
        private DateTime m_LastFailureTime;
        private Exception m_LastFailure;

        private State m_State;

        // BrianSp: Hang on the the MAK... not sure how good of an idea this is (leaked MAKs are probably a violation of TiVo usage agreements),
        // but the idea is that if the original TiVo is lost, an encrypted file is still later decryptable.
        private string m_TivoMak;

        private string m_Name;

        // BrianSp: The uri provided by the TiVo query response for this item.  One potential issue is that it encodes the TiVo's IP address,
        // which could potentially change from discovery to download in a DHCP environment especially.
        private string m_TivoUri;

        // BrianSp: TiVo declares an item's size, but this does not end up being the same as the size of the downloaded file.  Not sure why, does
        // TiVo transcode to MPEG-2 on the way out from some internal native format?  Anyway the actual size is typically anywhere from 40-90% of
        // this.
        private ulong m_TivoSize;

        // BrianSp: Note the machine name that actually did the transcoding.  Since transcoding can be delicate, it helps to be able to track down
        // the source of defective final media.
        private string m_TranscodedBy;

        // BrianSp: These exist only so you can override these paths by directly altering the serialized xml for the item.  This allows you to
        // (for instance) rename a file in the pipeline and not have the system think it's gone missing.
        private string m_TivoDownloadedName;
        private string m_TivoDecryptedName;
        private string m_TranscodedName;
        #endregion

        #region Instance Properties

        public string TivoDownloadedPath
        {
            get
            {
                string fileName = m_Name + ".TiVo";
                if (m_TivoDownloadedName != null)
                    fileName = m_TivoDownloadedName;

                return Path.Combine(m_Settings.TivoDownloadRootPath, fileName);
            }
        }

        private bool ApparentlyTivoDownloaded
        {
            get
            {
                if (!File.Exists(TivoDownloadedPath))
                    return false;

                FileInfo info = new FileInfo(TivoDownloadedPath);
                double ratio = (double)info.Length / (double)m_TivoSize;

                // BrianSp: Empirical value.  Actual downloaded sizes is observed to be 40-90% of TivoSize.  So this
                // definitely leaves room for error, but at least catches really small files that are clearly aborted
                // downloads.
                return ratio > 0.2f;
            }
        }

        public string TivoDecryptedPath
        {
            get
            {
                // BrianSp: TiVo apparently provides encrypted MPEG-2, but this could potentially change.  Something to watch out for.
                string fileName = m_Name + ".mpg";
                if (m_TivoDecryptedName != null)
                    fileName = m_TivoDecryptedName;

                return Path.Combine(m_Settings.TivoDecryptRootPath, fileName);
            }
        }

        private bool ApparentlyTivoDecrypted
        {
            get
            {
                if (!File.Exists(TivoDecryptedPath))
                    return false;

                FileInfo sourceInfo = new FileInfo(TivoDownloadedPath);
                FileInfo destInfo = new FileInfo(TivoDecryptedPath);

                double ratio = (double)destInfo.Length / (double)sourceInfo.Length;

                // BrianSp: This is an empirical value, but appears to be quite consistent.
                return ratio > 0.999f;
            }
        }

        public string TranscodedPath
        {
            get
            {
                string fileName = m_Name + m_Settings.TranscodedExtension;
                if (m_TranscodedName != null)
                    fileName = m_TranscodedName;

                return Path.Combine(m_Settings.TranscodeRootPath, fileName);
            }
        }

        private bool ApparentlyTranscoded
        {
            get
            {
                if (!File.Exists(TranscodedPath))
                    return false;

                FileInfo sourceInfo = new FileInfo(TivoDecryptedPath);
                FileInfo destInfo = new FileInfo(TranscodedPath);

                double ratio = (double)destInfo.Length / (double)sourceInfo.Length;

                // $$BrianSp: this is a really arbitrary value, and is transcoder specific.  Need a heuristic more
                // deterministic than file size ratios.
                return ratio > 0.05;
            }
        }

        #endregion

        #region Instance Methods

        private Item(string name, ulong size, string uri, FileStream file, Tivo tivo, Settings settings)
        {
            SetTransientContext(file, tivo, settings);

            m_TivoMak = m_Settings.TivoMak;
            m_Name = name;
            m_Failures = 0;
            m_TivoSize = size;
            m_TivoUri = uri;
        }

        private void SetTransientContext(FileStream file, Tivo tivo, Settings settings)
        {
            m_Tivo = tivo;
            m_Settings = settings;
            m_File = file;
        }

        public void Dispose()
        {
            if (m_File != null)
            {
                m_File.Flush();
                m_File.Close();
                m_File.Dispose();
                m_File = null;
            }

            if (m_TivoDownloadFile != null)
            {
                m_TivoDownloadFile.Flush();
                m_TivoDownloadFile.Close();
                m_TivoDownloadFile.Dispose();
                m_TivoDownloadFile = null;
            }

            if (m_FileBuffer != null)
            {
                m_FileBuffer.Flush();
                m_FileBuffer.Close();
                m_FileBuffer.Dispose();
                m_FileBuffer = null;
            }
        }

        private void SetState(State state)
        {
            m_State = state;
            Save();
        }

        private void Save()
        {
            // BrianSp: Nuke any previous file contents.
            m_File.SetLength(0);

            SoapFormatter formatter = new SoapFormatter();
            formatter.AssemblyFormat = System.Runtime.Serialization.Formatters.FormatterAssemblyStyle.Simple;
            formatter.Serialize(m_File, this);

            m_File.Flush();
        }

        // BrianSp: This guy returns true if any work was done, which tells the caller to exit any in-progress traversal
        // and loop around to start over.  This way we don't do any more than one lengthy operation at a time without
        // sleeping the amount specified in the config file.  Each pipeline method is responsible for ensuring the item
        // is in the appropriate state to perform that action, otherwise it should return false.
        public bool Progress()
        {
            if (m_LastFailure != null)
            {
                if (DateTime.Now < m_LastFailureTime.AddHours(Math.Pow(3, m_Failures)))
                    return false;
            }

            // BrianSp: It might be better to reverse the order of these actions, so that if an item (potentially by external 
            // means) was able to reach a state by skipping intermediate ones, those intermediate states will not be performed.
            // Doing that may require more work within the actions so they can recognize the apparent state even if the saved
            // state is a more primitive one.

            if (m_Settings.TivoIpAddress != null)
            {
                bool didWork = TivoDownload();
                if (didWork)
                    return true;
            }

            if (m_Settings.TivoDecrypter != null)
            {
                bool didWork = TivoDecrypt();
                if (didWork)
                    return true;
            }

            if (m_Settings.Transcoder != null)
            {
                bool didWork = Transcode();
                if (didWork)
                    return true;
            }

            return false;
        }

        public bool TivoDownload()
        {
            try
            {
                // BrianSp: Make sure the item is at least discovered.  Since an item is locked during downloading,
                // if we reach this point and find that we are already in the downloading state, that indicates a previously
                // aborted download attempt.  In that case we go ahead and redownload.
                if (m_State != Item.State.Discovered && m_State != Item.State.TivoDownloading)
                    return false;

                bool didWork = false;

                // BrianSp: We perform a heuristic analysis to determine if the action may have been performed outside the context
                // of this app.  That way the app can be applied to a previously processed data set and not unnecessarily redo a bunch
                // of work.
                if (!ApparentlyTivoDownloaded)
                {
                    // BrianSp: Log this even if we aren't doing work, since the whole point of setting "Do Work" = false is to run in a mode where
                    // the app will report what work would have been performed if "Do Work" == true.
                    Log.WriteLine(string.Format("TivoDownloading \"{0}\"", TivoDownloadedPath));

                    if (m_Settings.DoWork)
                    {
                        if (m_State != Item.State.TivoDownloading)
                            SetState(State.TivoDownloading);

                        m_TivoDownloadFile = new FileStream(TivoDownloadedPath, FileMode.Create, FileAccess.Write, FileShare.Read, 1, true);
                        m_FileBuffer = new MemoryStream(0x100000);

                        // BrianSp: On the one hand, this allows the TiVo address to change from time of discovery, but it also means
                        // multiple TiVos are not supported... but they aren't anyway (yet) for various other reasons.
                        UriBuilder uri = new UriBuilder(m_TivoUri);
                        uri.Host = m_Settings.TivoIpAddress;

                        WaitHandle wait = m_Tivo.SendRequest(uri.ToString(), new AsyncCallback(StaticTivoDownloadReadCallback), this);
                        m_Tivo.CompleteRequest(wait);

                        didWork = true;

                        ClearFailure();
                        SetState(State.TivoDownloaded);
                    }
                }
                else
                {
                    SetState(State.TivoDownloaded);
                }

                return didWork;
            }
            catch (Exception e)
            {
                RecordFailure(e);

                return false;
            }
        }

        private void TivoDownloadReadCallback(IAsyncResult result)
        {
            try
            {
                int read = m_Tivo.m_ResponseStream.EndRead(result);
                if (read > 0)
                {
                    // BrianSp: Use this lock to protect against the unlikely case of the next read returning before this one finishes the
                    // following code block.  That could result in a corrupted output file potentially.
                    lock (m_FileBuffer)
                    {
                        // BrianSp: Immediately buffer the chunk and post another read.
                        m_FileBuffer.Write(m_Tivo.m_Buffer, 0, read);
                        m_Tivo.m_ResponseStream.BeginRead(m_Tivo.m_Buffer, 0, m_Tivo.m_Buffer.Length, new AsyncCallback(StaticTivoDownloadReadCallback), this);

                        // BrianSp: If we've accumulated enough bytes (must be at least 64KB for this file write to be performed asynchronously
                        // according to MSDN) then write it into the destination file.
                        if (m_FileBuffer.Length > 0x100000)
                        {
                            byte[] buffer = m_FileBuffer.ToArray();
                            m_FileBuffer.SetLength(0);
                            m_TivoDownloadFile.BeginWrite(buffer, 0, buffer.Length, new AsyncCallback(StaticTivoDownloadWriteCallback), this);
                        }
                    }
                }
                else
                {
                    m_Tivo.Dispose();
                }
            }
            catch (Exception e)
            {
                m_Tivo.Dispose();
                Log.Write(e);
            }
        }

        private void TivoDownloadWriteCallback(IAsyncResult result)
        {
            try
            {
                m_TivoDownloadFile.EndWrite(result);
            }
            catch (Exception e)
            {
                m_Tivo.Dispose();
                Log.Write(e);
            }
        }

        public bool TivoDecrypt()
        {
            try
            {
                // BrianSp: Make sure the item is at least downloaded.  Since an item is locked during decrypting,
                // if we reach this point and find that we are already in the decrypting state, that indicates a previously
                // aborted decryption attempt.  In that case we go ahead and retry the decryption.
                if (m_State != Item.State.TivoDownloaded && m_State != Item.State.TivoDecrypting)
                    return false;

                bool didWork = false;

                // BrianSp: We perform a heuristic analysis to determine if the action may have been performed outside the context
                // of this app.  That way the app can be applied to a previously processed data set and not unnecessarily redo a bunch
                // of work.
                if (!ApparentlyTivoDecrypted)
                {
                    // BrianSp: Log this even if we aren't doing work, since the whole point of setting "Do Work" = false is to run in a mode where
                    // the app will report what work would have been performed if "Do Work" == true.
                    Log.WriteLine(string.Format("TivoDecrypting \"{0}\"", TivoDownloadedPath));

                    if (m_Settings.DoWork)
                    {
                        if (m_State != Item.State.TivoDecrypting)
                            SetState(State.TivoDecrypting);

                        string cmd = string.Format(m_Settings.TivoDecrypter, m_TivoMak, TivoDownloadedPath, TivoDecryptedPath);

                        int result = Process.ExecuteCmd(cmd, ProcessPriorityClass.BelowNormal, TimeSpan.FromMinutes(m_Settings.TivoDecrypterTimeoutMinutes).TotalSeconds);
                        if (m_Settings.TivoDecrypterSuccessExitCodes.Count > 0 && !m_Settings.TivoDecrypterSuccessExitCodes.ContainsKey(result))
                            throw new Exception(string.Format("\"{0}\" returned {1}", cmd, result));

                        didWork = true;

                        ClearFailure();
                        SetState(State.TivoDecrypted);
                    }
                }
                else
                {
                    SetState(State.TivoDecrypted);
                }

                return didWork;
            }
            catch (Exception e)
            {
                RecordFailure(e);

                return false;
            }
        }

        public bool Transcode()
        {
            try
            {
                // BrianSp: Make sure the item is at least decrypted.  Since an item is locked during transcoding,
                // if we reach this point and find that we are already in the transcoding state, that indicates a previously
                // aborted transcoding attempt.  In that case we go ahead and retry the transcoding.
                if (m_State != Item.State.TivoDecrypted && m_State != Item.State.Transcoding)
                    return false;

                bool didWork = false;

                // BrianSp: We perform a heuristic analysis to determine if the action may have been performed outside the context
                // of this app.  That way the app can be applied to a previously processed data set and not unnecessarily redo a bunch
                // of work.
                if (!ApparentlyTranscoded)
                {
                    // BrianSp: Log this even if we aren't doing work, since the whole point of setting "Do Work" = false is to run in a mode where
                    // the app will report what work would have been performed if "Do Work" == true.
                    Log.WriteLine(string.Format("Transcoding \"{0}\"", TranscodedPath));

                    if (m_Settings.DoWork)
                    {
                        m_TranscodedBy = SystemInformation.ComputerName;
                        Save();

                        if (m_State != Item.State.Transcoding)
                            SetState(State.Transcoding);

                        string cmd = string.Format(m_Settings.Transcoder, TivoDecryptedPath, TranscodedPath);

                        int result = Process.ExecuteCmd(cmd, ProcessPriorityClass.BelowNormal, TimeSpan.FromMinutes(m_Settings.TranscoderTimeoutMinutes).TotalSeconds);
                        if (m_Settings.TranscoderSuccessExitCodes.Count > 0 && !m_Settings.TranscoderSuccessExitCodes.ContainsKey(result))
                            throw new Exception(string.Format("\"{0}\" returned {1}", cmd, result));

                        didWork = true;

                        ClearFailure();
                        SetState(State.Transcoded);
                    }
                }
                else
                {
                    SetState(State.Transcoded);
                }

                return didWork;
            }
            catch (Exception e)
            {
                RecordFailure(e);

                return false;
            }
        }

        private void RecordFailure(Exception e)
        {
            Log.Write(e);

            m_Failures++;
            m_LastFailure = e;
            m_LastFailureTime = DateTime.Now;

            Save();
        }

        private void ClearFailure()
        {
            m_Failures = 0;
            m_LastFailure = null;
            m_LastFailureTime = DateTime.MinValue;
        }

        #endregion
    }
}
